몇 천 페이지의 유저 가이드를 새로 만들며

📅 2023. 10. 22

FECONF 2023 몇 천 페이지의 유저 가이드를 새로 만들며 영상의 내용을 정리한 글입니다.

서버 사이드 렌더링은 빠르게 화면을 사용자에게 제공해준다는 점에서 매력적이지만, 예상치 못한 문제들이 발생하기도 합니다.

Next.js(App Router)와 Contentful (Headless CMS) 조합을 통해 몇 천 페이지가 넘는 유저 가이드를 최적화하고, 최적화 과정에서 일어난 다양한 문제들을 어떻게 해결했는지 소개합니다.

발표자: 이찬희 (AB180)

  • Zendesk 레이아웃은 중복되는 레이아웃에 대해 직접 일일이 수정해줘야했기 때문에, 시스템을 갈아엎기로 결정
  • 결정한 기술스택은 Headless CMS(Contentful) + Next.js (App Router)
  • 그러나, 새로운 요구사항의 추가로 문제에 봉착함
    • "Command + F"로 특정 내용을 검색했을 때, 해당 내용이 아코디언 안에 들어있다면 자동으로 아코디언이 펼쳐졌으면 좋겠습니다.
    • 참고차, 경쟁사 가이드도 올려둡니다.
  • 'Cmd+F 이후의 모든 키보드 이벤트를 기록이라도 하나...?'
  • '한글같은 조합형 문자면 추측도 어려운데...?'
  • 찾아낸 해답은 hidden=until-found 속성과 addEventListener("beforematch", ... 속성
    • hidden=until-found은 검색에서 일치하는 항목이 해당 영역내에 있을 때 beformatch 이벤트를 발생시킴
    • 상대적으로 최근에 추가된 HTML 스펙 (Chrome 102부터 가능)
    • hidden=until-found를 적용하면 content-visibility:hidden 스타일이 기본 적용됨
      • 요소는 숨겨져 있지만 렌더링 상태는 유지
      • 마치 display: none + visibility: hidden 과 같음
  • 그러나, 실제로 React에 적용해보니 HTML 결과가 다르게 나타남
    • <div hidden="until-found">가 아닌 <div hidden>가 됨
  • 왜 이런 문제가 발생했을까?
  • 우리는 ReactDOM에 주목해야 함
    • hidden 속성을 다른 값으로 바꾼 곳이 ReactDOM이기 때문
  • ReactDOM의 코드를 살펴보면, hidden 속성 값을 빈 문자열로 치환해버림
    • React 팀도 이를 인지하고 있고 PR도 올라와 있으나, 현재 서버 컴포넌트 작업으로 인해 코드 변경이 많이 일어난 상태라서 patch-package 적용은 고려하지 않기로 결정
  • [hidden="until-found"]는 올바른 HTML 속성
  • 최신 스펙이기에 리액트에 반영되지 않아 잘못된 값으로 인식
  • 결론적으로, 라이브러리가 접근성을 위한 기능을 제한한 것
    • "구현이 어려울 것 같아요"라고 말해야 할까?
  • 만약 ReactDOM을 건드릴 수 없다면, 렌더링에 관여하는 "DOM"을 속이면 된다.
  • Q. JSX에서 이 코드는 동작할까요?
    • <div STYLE="background:red;" />
    • 정답: 동작한다.
    • 모든 HTML은 Case-Insensitive
    • ReactDOM은 정의된 소문자, /on[A-Z]로 시작하는 속성만 검증
  • 대문자로 속성을 주고, DOM에 beforematch 이벤트 리스너를 추가하면 끝
  • 기존 코드를 <div HIDDEN="until-found" ref={ref}>로 바꾸니 정상 동작
  • 더 나아가서, 아코디언에 애니메이션을 적용하고 싶다면 어떻게 할 수 있을까?
    • content-visibility: hidden 스타일을 애니메이션 실행 중에는 제거해야 함
    • 애니메이션 실행 전/후로 상태 변경
    • 애니메이션이 실행 중이거나 열려있을 때 hidden 속성을 비우도록 처리
const [isAnimating, setIsAnimating] = useState(false);

setIsAnimating(true);
// animate는 Framer Motion에서 제공하는 함수
animate(
	ref.current,
	nextIsOpened ? variants.show : variants.hide,
	{
		onComplete() {
			setIsAnimating(false);
		}
	}
);

const hiddenProp = isAnimating || isOpen
	? undefined
	: 'until-found';

<div ref={ref} HIDDEN={hiddenProp} />
  • 정상 동작!
  • 두 번째 문제: 가이드를 이전하며 생긴 일
  • CMS 보일러플레이트들은 Static Site Generation 사용
    • 콘텐츠 변경이 자주 있지 않음
    • 초기 로딩 속도, SEO 관련 지표 향상 등이 주 목적
  • '유저 가이드는 변경이 적고, 변경되는 페이지는 Next.js ISR을 적용하면 되지 않을까?'
  • PW팀은 기존 가이드를 전수 검사 및 수정을 통해 하나씩 새로운 CMS(Contentful)로 글을 이전
  • 기존에는 CMS에서 업데이트가 일어나면, 웹훅을 보내 새로운 빌드를 실행함
  • 문제가 된 부분은 노란색 영역
  • 만약 가이드가 삭제되거나, 제목이 바뀌거나, 가이드 간 순서가 바뀌게 되면 동일한 사이드 바를 노출하기 위해 업데이트가 필요함
    • 가이드 숫자 * 지원 언어 수(4개) 만큼 재생성 요청을 보내야 함
    • 1,000개 * (한/영/일/중) 요청이 한 번에 서버로 전송됨
    • 서버 과부하 발생
  • 따라서, 느리지만 확실한 새로운 빌드를 돌려야 했음
  • '조금 더 좋은 방법은 없을까?'
  • Theo Browne의 Is Next.js App Router SLOW? Performance Deep Dive라는 영상을 보게 됨
    • Theo Browne은 트위치에서 Seinor Enginner로 일하다 스타트업 CEO 겸 개발 관련 Youtube를 운영 중
  • "App Router는 중복되는 요청을 내장된 데이터 캐시에서 가져오기에 렌더링이 더욱 빠르다"
    • 벤치마크 결과, App Router가 Pages Router보다 3배 이상 빠르다고 주장
  • 내용의 옳고 그름을 떠나서, 렌더링의 단위를 바꿔볼 필요가 있다고 생각
  • 기존에는 SSG/SSR/ISR 등 페이지 단위로 생각
  • 페이지의 구성 요소를 살펴보면 동적인 것과 정적인 것이 존재
  • 만약 이를 네트워크 요청 단위로 생각해본다면 세 가지로 생각해볼 수 있음
    • 태그/그룹핑: 화면을 구성하는데 필요한 요청들이 있나?
    • 캐싱: 모든 네트워크 요청을 항상 새로 받아와야할까?
    • 스트리밍: 요청이 오래 걸리니 점진적으로 보여주게 할까?
  • 실제로 Next.js 공식 문서에서는 SSG/SSR/ISR과 같은 용어보다는 Static Rendering, Dynamic Rendering 같은 용어를 자주 사용함
  • 유저 가이드는 CMS에서 데이터를 받아오기 때문에 네트워크 요청이 차지하는 비중이 큼

  • '서비스에서 정적인 부분과 동적인 부분을 나누고 정적인 부분이 데이터 캐시를 더 적극적으로 활용하도록 변경한다면, 초기 빌드 시간이 줄어들고 업데이트가 빠르게 반영되는 환경을 만들 수 있지 않을까?'

  • 기존 정적 생성 로직을 모두 삭제

  • 만약 콘텐츠 업데이트가 필요하다면, 태그를 활용해 묶어있는 요청들의 캐시를 초기화해주면 됨

  • 추가로 궁금할 수 있는 내용

  • "모든 페이지에 한 번은 접속해야 캐시가 돌지 않나요?"

    • 캐시 히트를 위해 한 번은 접속이 필요
    • 유저 가이드는 사이트맵이 있는 서비스
    • 웹 크롤러는 사이트맵에 정의된 모든 페이지에 1회 이상 방문할 것
    • 웹 크롤러에 의해 데이터 캐시가 활성될 것
    • Search Console 등록 후 Web Vitals만 모니터링하면 괜찮을 것
  • 그 외 언급하지 못한 문제들

    • 수많은 Next.js 버그들
    • 가이드를 출력해 사용하는 분들을 위한 프린트 모드 (window.matchMedia('@media print') vs beforeprint())
    • Shared Component가 일으킬 수 있는 빌드 오버헤드
    • JSON + LD로 구조화된 검색 데이터 생성 방법
  • 마무리

  • "문제 해결 방법이 너무 단순한 것 같아요" "흑마법 아닌가요?"

  • 아코디언 문제를 통해

    • 프레임워크 / 라이브러리가 기능, 접근성 구현을 제한할 수 있다는 것을 배울 수 있었음
    • 개발자는 구현할 것인가? 한다면 어떻게, 어디까지 할 것인가?
    • 문제에 관여하는 주체에 동작, 영향을 나누어 파악하기
      • 렌더링 단계를 나누어 ReactDOM이 관여함을 확인
      • HTML이 대소문자를 가리지 않는 특성을 활용해 우회
  • 정적 페이지 생성이 느려진 문제를 통해

    • 단순한 것도 스케일이 커지면 복잡도 역시 늘어난다
    • 스케일이 커졌을 때도 기존의 방법론이 여전히 유효한가?
    • 렌더링 관점을 페이지 단위 -> 네트워크 요청 단위로 바라보기
    • 우리는 SSG가 아니라 Static Rendering이 필요했음
    • 여담: 페이지 단위를 다른 단위로 바라보는 개념은 Partial Hydration을 이용하는 여러 프레임워크(Astro, Solid)에서 확인해볼 수 있음
  • "제품의 특성을 파악하고, 영향을 주는 요인을 찾아가며, 보다 단순한 해답을 찾아가기"

  • Q&A

    • Q. App Router를 사용할 때, 로그인 여부에 따른 UI 분기가 없었어요. 헤더나 쿠키에 접근하는 요소를 RSC(React Server Component)에서 사용하면 만들고 싶은 화면이 정적이여도 강제로 동적이 되는 경우가 있었거든요. 이런 문제를 겪어보셨는지, 해결하셨는지 궁금합니다.
    • A. 저도 뚜렷한 해결 방법은 찾지 못했어요. Next.js에서도 이런 부분을 강제하고 있다는 느낌도 들었어요. 실 프로덕트를 만들면서 확인이 필요할 것 같아요.
    • Q. 아코디언 만드실 때, 더 복잡한 방법으로도 해결할 수 있었을텐데 어떤 생각의 흐름으로 말씀하신 방법에 도달한 건지 과정이 궁금했습니다.
    • A. 문제 해결에 걸리는 제한 시간을 걸어놓고 생각하고, 제한 시간안에 찾은 방법으로 문제를 해결합니다. 유사 사이트를 리버스 엔지니어링 하기도 합니다. 다양한 방법으로 검색을 하고 싶으면 LogRocket에서 유의미한 데이터를 얻기도 합니다.
    • Q. 아코디언은 html details 태그로도 구현할 수 있는데, 다른 제약사항이 있어서 details 태그를 사용하지 않으신건지 궁금합니다.
    • A. 애니메이션이 적용되지 않은 문제가 있었고, 크롬 60 버전에서 details 태그를 span 태그로 잘못 인식하는 버그가 3% 정도 존재했었습니다. 이 부분이 좀 찝찝해서 div로 구현했습니다.